Micron Document
🎖️GitЯра🎖️

Commit 0336ef00a86ce3677982048267b3dad4f184af31


Parents : e8ff76d
Author : simulationstation <32910678+simulationstation@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-12T02:11:40-10:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-12T12:11:40Z

fix(service): load persisted MeshLog cleanup policy (#6621)

Changes
Diff

diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefsImpl.kt
index bc5be34343..f2d8868410 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefsImpl.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefsImpl.kt
@@ -23,12 +23,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.MeshLogDataStore
+import org.meshtastic.core.repository.MeshLogCleanupPolicy
import org.meshtastic.core.repository.MeshLogPrefs
@Single
@@ -53,6 +55,13 @@ class MeshLogPrefsImpl(private val dataStore: MeshLogDataStore, dispatchers: Cor
scope.launch { dataStore.edit { it[KEY_LOGGING_ENABLED_PREF] = enabled } }
}
+ override suspend fun awaitCleanupPolicy(): MeshLogCleanupPolicy = dataStore.data.first().let { preferences ->
+ MeshLogCleanupPolicy(
+ loggingEnabled = preferences[KEY_LOGGING_ENABLED_PREF] ?: DEFAULT_LOGGING_ENABLED,
+ retentionDays = preferences[KEY_RETENTION_DAYS_PREF] ?: DEFAULT_RETENTION_DAYS,
+ )
+ }
+
companion object {
const val RETENTION_DAYS_KEY = "meshlog_retention_days"
const val LOGGING_ENABLED_KEY = "meshlog_logging_enabled"

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index 0f50a68308..068047d07d 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -46,6 +46,9 @@ interface FilterPrefs {
fun setFilterWords(words: Set<String>)
}
+/** Persisted policy used by background mesh-log cleanup. */
+data class MeshLogCleanupPolicy(val loggingEnabled: Boolean, val retentionDays: Int)
+
/** Reactive interface for mesh log preferences. */
interface MeshLogPrefs {
val retentionDays: StateFlow<Int>
@@ -56,6 +59,9 @@ interface MeshLogPrefs {
fun setLoggingEnabled(enabled: Boolean)
+ /** Both cleanup settings from one persisted snapshot; suspends until the store's initial load completes. */
+ suspend fun awaitCleanupPolicy(): MeshLogCleanupPolicy
+
companion object {
const val DEFAULT_RETENTION_DAYS = 30

diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts
index 97f0394ddc..c89721dcc0 100644
--- a/core/service/build.gradle.kts
+++ b/core/service/build.gradle.kts
@@ -52,7 +52,12 @@ kotlin {
implementation(libs.koin.androidx.workmanager)
}
- getByName("androidHostTest") { dependencies { implementation(libs.androidx.work.testing) } }
+ getByName("androidHostTest") {
+ dependencies {
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.androidx.work.testing)
+ }
+ }
commonTest.dependencies { implementation(projects.core.testing) }
}

diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshLogCleanupWorkerTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshLogCleanupWorkerTest.kt
new file mode 100644
index 0000000000..d21d2c1f97
--- /dev/null
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshLogCleanupWorkerTest.kt
@@ -0,0 +1,198 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.service
+
+import android.content.Context
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.preferencesOf
+import androidx.test.core.app.ApplicationProvider
+import androidx.work.ListenableWorker
+import androidx.work.WorkerFactory
+import androidx.work.WorkerParameters
+import androidx.work.testing.TestListenableWorkerBuilder
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.emitAll
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.MeshLogDataStore
+import org.meshtastic.core.prefs.meshlog.MeshLogPrefsImpl
+import org.meshtastic.core.service.worker.MeshLogCleanupWorker
+import org.meshtastic.core.testing.FakeMeshLogRepository
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class MeshLogCleanupWorkerTest {
+ private lateinit var context: Context
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ }
+
+ @Test
+ fun `persisted disabled policy never deletes after a delayed cold load`() = runTest {
+ val harness = createHarness(loggingEnabled = false, retentionDays = 7, StandardTestDispatcher(testScheduler))
+ runCurrent()
+ val reactiveCollectors = harness.dataStore.collectionCount
+
+ val result = async { harness.worker.doWork() }
+ runCurrent()
+
+ assertNull(harness.repository.lastDeletedOlderThan, "synthetic defaults must not drive cleanup")
+ assertFalse(result.isCompleted)
+ assertEquals(reactiveCollectors + 1, harness.dataStore.collectionCount)
+
+ harness.dataStore.releaseLoad()
+ runCurrent()
+
+ assertEquals(ListenableWorker.Result.success(), result.await())
+ assertNull(harness.repository.lastDeletedOlderThan)
+ }
+
+ @Test
+ fun `persisted zero retention never deletes after a delayed cold load`() = runTest {
+ val harness = createHarness(loggingEnabled = true, retentionDays = 0, StandardTestDispatcher(testScheduler))
+ runCurrent()
+ val reactiveCollectors = harness.dataStore.collectionCount
+
+ val result = async { harness.worker.doWork() }
+ runCurrent()
+
+ assertNull(harness.repository.lastDeletedOlderThan, "synthetic defaults must not drive cleanup")
+ assertFalse(result.isCompleted)
+ assertEquals(reactiveCollectors + 1, harness.dataStore.collectionCount)
+
+ harness.dataStore.releaseLoad()
+ runCurrent()
+
+ assertEquals(ListenableWorker.Result.success(), result.await())
+ assertNull(harness.repository.lastDeletedOlderThan)
+ }
+
+ @Test
+ fun `persisted positive retention deletes once with the loaded policy`() = runTest {
+ val harness = createHarness(loggingEnabled = true, retentionDays = 7, StandardTestDispatcher(testScheduler))
+ runCurrent()
+ val reactiveCollectors = harness.dataStore.collectionCount
+
+ val result = async { harness.worker.doWork() }
+ runCurrent()
+
+ assertNull(harness.repository.lastDeletedOlderThan, "cleanup must wait for persisted policy")
+ assertFalse(result.isCompleted)
+ assertEquals(reactiveCollectors + 1, harness.dataStore.collectionCount)
+
+ harness.dataStore.releaseLoad()
+ runCurrent()
+
+ assertEquals(ListenableWorker.Result.success(), result.await())
+ assertEquals(7, harness.repository.lastDeletedOlderThan)
+ }
+
+ @Test
+ fun `cancellation while loading persisted policy propagates`() = runTest {
+ val harness = createHarness(loggingEnabled = true, retentionDays = 7, StandardTestDispatcher(testScheduler))
+ runCurrent()
+
+ val result = async { harness.worker.doWork() }
+ runCurrent()
+ assertFalse(result.isCompleted)
+
+ val cancellation = CancellationException("persisted policy load cancelled")
+ harness.dataStore.failLoad(cancellation)
+ runCurrent()
+
+ val thrown = assertFailsWith<CancellationException> { result.await() }
+ assertEquals(cancellation.message, thrown.message)
+ assertNull(harness.repository.lastDeletedOlderThan)
+ }
+
+ private fun createHarness(loggingEnabled: Boolean, retentionDays: Int, dispatcher: CoroutineDispatcher): Harness {
+ val dataStore = DelayedMeshLogDataStore(loggingEnabled, retentionDays)
+ val meshLogPrefs =
+ MeshLogPrefsImpl(dataStore, CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher))
+ val repository = FakeMeshLogRepository()
+ val worker =
+ TestListenableWorkerBuilder<MeshLogCleanupWorker>(context)
+ .setWorkerFactory(
+ object : WorkerFactory() {
+ override fun createWorker(
+ appContext: Context,
+ workerClassName: String,
+ workerParameters: WorkerParameters,
+ ): ListenableWorker =
+ MeshLogCleanupWorker(appContext, workerParameters, repository, meshLogPrefs)
+ },
+ )
+ .build()
+ return Harness(worker, repository, dataStore)
+ }
+
+ private data class Harness(
+ val worker: MeshLogCleanupWorker,
+ val repository: FakeMeshLogRepository,
+ val dataStore: DelayedMeshLogDataStore,
+ )
+
+ private class DelayedMeshLogDataStore(loggingEnabled: Boolean, retentionDays: Int) : MeshLogDataStore {
+ private val loadGate = CompletableDeferred<Unit>()
+ private val persisted =
+ MutableStateFlow(
+ preferencesOf(
+ MeshLogPrefsImpl.KEY_LOGGING_ENABLED_PREF to loggingEnabled,
+ MeshLogPrefsImpl.KEY_RETENTION_DAYS_PREF to retentionDays,
+ ),
+ )
+
+ var collectionCount: Int = 0
+ private set
+
+ override val data: Flow<Preferences> = flow {
+ collectionCount += 1
+ loadGate.await()
+ emitAll(persisted)
+ }
+
+ override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences =
+ transform(persisted.value).also { persisted.value = it }
+
+ fun releaseLoad() {
+ loadGate.complete(Unit)
+ }
+
+ fun failLoad(cause: Throwable) {
+ loadGate.completeExceptionally(cause)
+ }
+ }
+}

diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/worker/MeshLogCleanupWorker.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/worker/MeshLogCleanupWorker.kt
index 5abf43b54d..d458c9ee56 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/worker/MeshLogCleanupWorker.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/worker/MeshLogCleanupWorker.kt
@@ -20,6 +20,7 @@ import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
import org.koin.android.annotation.KoinWorker
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogRepository
@@ -35,9 +36,10 @@ class MeshLogCleanupWorker(
@Suppress("TooGenericExceptionCaught")
override suspend fun doWork(): Result = try {
- val retentionDays = meshLogPrefs.retentionDays.value
+ val policy = meshLogPrefs.awaitCleanupPolicy()
+ val retentionDays = policy.retentionDays
val retentionWindow = MeshLogRetention.windowOrNull(retentionDays)
- if (!meshLogPrefs.loggingEnabled.value) {
+ if (!policy.loggingEnabled) {
logger.i { "Skipping cleanup because mesh log storage is disabled" }
} else if (retentionWindow == null) {
logger.i { "Skipping cleanup because retention is set to never delete" }
@@ -47,6 +49,8 @@ class MeshLogCleanupWorker(
logger.i { "Successfully cleaned old MeshLog entries" }
}
Result.success()
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
logger.e(e) { "Failed to clean MeshLog entries" }
Result.failure()

diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshLogPrefs.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshLogPrefs.kt
index 5461a1d4e9..364d731586 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshLogPrefs.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshLogPrefs.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.core.testing
+import org.meshtastic.core.repository.MeshLogCleanupPolicy
import org.meshtastic.core.repository.MeshLogPrefs
class FakeMeshLogPrefs :
@@ -34,4 +35,7 @@ class FakeMeshLogPrefs :
override fun setLoggingEnabled(enabled: Boolean) {
_loggingEnabled.value = enabled
}
+
+ override suspend fun awaitCleanupPolicy(): MeshLogCleanupPolicy =
+ MeshLogCleanupPolicy(loggingEnabled = loggingEnabled.value, retentionDays = retentionDays.value)
}

Served by rngit 1.5.0 - Generated in 0.1s